#!/usr/bin/env python
# -*- coding: utf-8 -*-

__version__ = '0.1.3'

__url__ = 'http://code.google.com/p/backport/'

__all__ = (
    'translate_project',
    'ProjectTranslator'
)

import imp
import os
import re
import shutil
import subprocess
import sys

try:
    from setup import PY3_PACKAGES
    from setup import PY2_PACKAGES
except ImportError:
    required_setup = """This project's setup.py must define the
following variables:
  PY3_PACKAGES (a package_dir for the project's Python 3 source)
  PY2_PACKAGES (a package_dir for auto-translated Python 2 files)"""
    raise ImportError(required_setup)

try:
    from setup import TRANSLATE_FLAGS  # This is optional.
except ImportError:
    TRANSLATE_FLAGS = []  # If undefined, assign empty list.


########################################################################
# Convenience function.
########################################################################
def translate_project():
    """Copy project tree from source directory into destination
    directory and call the 3to2 for each Python source file.

    """
    translator = ProjectTranslator(PY3_PACKAGES, PY2_PACKAGES)
    translator.purge()  # Remove old or unused files.

    if translator.new_files:
        backport_python = os.getenv('BACKPORT_PYTHON')
        backport_3to2   = os.getenv('BACKPORT_3TO2')
        if backport_python and backport_3to2:
            command_args = [backport_python, backport_3to2, '--write',
                            '--nobackups', '--no-diffs']
            translator.translate(command_args, TRANSLATE_FLAGS)
        else:
            import warnings
            # The following message should not exceed 20 lines in length
            # or 79 characters in width (for display purposes).
            config_message = r"""
    The Python 3 source code for this project has been modified but
    `backport` was unable to translate these files into equivalent
    Python 2 code.

    Missing Environment Variables:
        BACKPORT_PYTHON (path to Python executable used to run 3to2)
        BACKPORT_3TO2   (path to 3to2 itself)

    On Unix systems, you can set environment variables in your
    ~/.bash_profile or other preferred configuration file like so:
        export BACKPORT_PYTHON=/usr/bin/python2.7
        export BACKPORT_3TO2=/usr/local/bin/3to2

    On Windows systems, you can set environment variables using the
    advanced settings options in the system properties interface or
    with a batch file like so:
        SET BACKPORT_PYTHON=C:\Python27\python.exe
        SET BACKPORT_3TO2=C:\Python27\Scripts\3to2"""
            warnings.warn(config_message)


########################################################################
# Main class definition.
########################################################################
class ProjectTranslator(object):
    _tokenfile = '.translated_code'  # For marking translated dirs.

    def __init__(self, srcpkg, dstpkg):
        self._dstdirs = sorted(dstpkg.values())
        self._validate_packages(srcpkg, dstpkg)
        self._mastermtime = os.path.getmtime(os.path.abspath(__file__))

        self.old_files = []
        self.new_files = []
        for package in sorted(srcpkg.keys()):
            srcdir = srcpkg[package]
            dstdir = dstpkg[package]

            srcfiles = self._getbasenames(srcdir)
            srcfiles = set(x for x in srcfiles if not x.endswith('.pyc'))
            dstfiles = self._getbasenames(dstdir)

            common = srcfiles.intersection(dstfiles)
            modified_files = self._findmodified(common, srcdir, dstdir)

            # Build list of out-of-date or unused files to remove.
            removed_files = dstfiles.difference(srcfiles)
            for file in modified_files.union(removed_files):
                if file != self._tokenfile:
                    self.old_files.append(os.path.join(dstdir, file))

            # Build list of modified or new files to copy and translate.
            added_files = srcfiles.difference(dstfiles)
            for file in modified_files.union(added_files):
                source      = os.path.join(srcdir, file)
                destination = os.path.join(dstdir, file)
                self.new_files.append((source, destination))

    def _validate_packages(self, srcpkg, dstpkg):
        """Validate source and destination package descriptions."""
        #message = 'Source and destination package_dir must have same keys.'
        message = ('PY3_PACKAGES and PY2_PACKAGES must have the same '
                   'keys.')
        assert set(srcpkg.keys()) == set(dstpkg.keys()), message

        #message = ('Source and destination package_dir must NOT share '
        #           'any of the same folders.')
        message = ('PY3_PACKAGES and PY2_PACKAGES must NOT share any '
                   'of the same folders.')
        assert not (set(srcpkg.values()) & set(dstpkg.values())), message

        for path in self._dstdirs:
            if os.path.exists(path):
                tokenpath = os.path.join(path, self._tokenfile)
                message = ("The path '%s' exists but token file '%s' "
                           "is missing.  This file system object "
                           "appears to be used for another purpose!" %
                           (path, tokenpath))
                assert os.path.exists(tokenpath), message

    def _getbasenames(self, folder):
        """Returns set of file basenames in given folder."""
        try:
            files = os.listdir(folder)
            def isvalid(x):
                path = os.path.normpath(os.path.join(folder, x))
                return os.path.isfile(path)
            files = set(x for x in files if isvalid(x))
        except OSError:
            files = set()
        return files

    def _findmodified(self, files, srcdir, dstdir):
        """Compare source and destination directories, return set of
        files for which source has changed since last translation."""
        srcmtime = lambda x: os.path.getmtime(os.path.join(srcdir, x))
        dstmtime = lambda x: os.path.getmtime(os.path.join(dstdir, x))
        if [x for x in files if self._mastermtime > dstmtime(x)]:  # any
            return set(files)
        return set(x for x in files if srcmtime(x) >= dstmtime(x))
        # Above, we use ">=" to include files that were copied but not
        # modified for one reason or another.  To handle these cases,
        # files will be os.utime/touched after translation so they
        # should *always* have a later timestamp (not equal).

    def purge(self):
        """Deletes out-of-date or unused files."""
        for path in self.old_files:
            os.remove(path)

    def translate(self, command_args, extra_flags=None):
        """Uses given commands to build translated version of project.
        command_args: list of command line arguments
        extra_flags: a list of tuples (regex, cmdline_flag)
        """
        if extra_flags is None:
            extra_flags = []

        for path in self._dstdirs:        # Create needed destination
            if not os.path.exists(path):  # folders and token files.
                os.makedirs(path)
                tokenpath = os.path.join(path, self._tokenfile)
                open(tokenpath, 'wb').close()

        for source, destination in self.new_files:  # Copy files and
            shutil.copy2(source, destination)       # translate Python
            if destination.endswith('.py'):         # source.
                extras = self._findflags(destination, extra_flags)
                self._check_call(command_args + extras + [destination])
            os.utime(destination, None)  # Update last-modified time.

    def _findflags(self, path, flaglst):
        """Return list of flags for all patterns matching path."""
        return [flag for pattern,flag in flaglst if re.search(pattern, path)]

    @staticmethod
    def _check_call(popenargs, *args, **kwargs):
        # Modeled after Python 2.5's subprocess.check_call function.
        retcode = subprocess.call(popenargs, *args, **kwargs)
        if retcode != 0:
            raise OSError(retcode, popenargs)
        return retcode


if __name__ == '__main__':
    translate_project()


#   Copyright 2011, 2012 Shawn Brown
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
#   implied. See the License for the specific language governing
#   permissions and limitations under the License.
